现在,我们终于进入到了逻辑层的开发,之前我们已经准备好了相关的数据并且已经让组件连接,这里会省不少事情。但是整个交互的逻辑还是比较复杂的,希望大家能够提前做好心理准备,迎接这个挑战吧。

首先把问题拆分一下,对播放器而言,进行交互的部分无非就是两个部分:mini版和全屏版。我们先从简单一些的mini版开始入手吧。

# mini播放器

mini播放器目前依赖的数据是播放状态和播放进度数据。

const { song, fullScreen, playing, percent } = props;

const { clickPlaying, setFullScreen } = props;

进度条这里的JSX代码也需要修改一下:

// 暂停的时候唱片也停止旋转
<img className={`play ${playing ? "": "pause"}`} src={song.al.picUrl} width="40" height="40" alt="img"/>
<ProgressCircle radius={32} percent={percent}>
  { playing ?
    <i className="icon-mini iconfont icon-pause" onClick={e => clickPlaying(e, false)}>&#xe650;</i>
    :
    <i className="icon-mini iconfont icon-play" onClick={e => clickPlaying(e, true)}>&#xe61e;</i>
  }
</ProgressCircle>

当然在父组件中也要做相应修改:

const clickPlaying = (e, state) => {
  e.stopPropagation();
  togglePlayingDispatch(state);
};
return (
  <div>
    <MiniPlayer
      song={currentSong}
      fullScreen={fullScreen}
      playing={playing}
      toggleFullScreen={toggleFullScreenDispatch}
      clickPlaying={clickPlaying}
    />
    <NormalPlayer
      song={currentSong}
      fullScreen={fullScreen}
      playing={playing}
      toggleFullScreen={toggleFullScreenDispatch}
      clickPlaying={clickPlaying}
    />
  </div>
)

# 初次完成播放

Ok, 现在我们来处理更复杂的全屏播放器部分。

首先定义必要的播放器属性:

//Player/index.js

//目前播放时间
const [currentTime, setCurrentTime] = useState(0);
//歌曲总时长
const [duration, setDuration] = useState(0);
//歌曲播放进度
let percent = isNaN(currentTime / duration) ? 0 : currentTime / duration;

同时需要接受redux中的currentIndex:

  const { fullScreen, playing, currentIndex, currentSong: immutableCurrentSong } = props;
  const { toggleFullScreenDispatch, togglePlayingDispatch, changeCurrentIndexDispatch, changeCurrentDispatch } = props;

  let currentSong = immutableCurrentSong.toJS();

我们现在的当务之急是让播放器能够播放, 所以现在我们需要放上我们的核心元素————audio标签:

//绑定ref
const audioRef = useRef();

return (
  <div>
    //...
    <audio ref={audioRef}></audio>
  </div>
)

现在先写一些逻辑:

//mock一份playList,后面直接从 redux 拿,现在只是为了调试播放效果。
const playList = [
    {
      ftype: 0,
      djId: 0,
      a: null,
      cd: '01',
      crbt: null,
      no: 1,
      st: 0,
      rt: '',
      cf: '',
      alia: [
        '手游《梦幻花园》苏州园林版推广曲'
      ],
      rtUrls: [],
      fee: 0,
      s_id: 0,
      copyright: 0,
      h: {
        br: 320000,
        fid: 0,
        size: 9400365,
        vd: -45814
      },
      mv: 0,
      al: {
        id: 84991301,
        name: '拾梦纪',
        picUrl: 'http://p1.music.126.net/M19SOoRMkcHmJvmGflXjXQ==/109951164627180052.jpg',
        tns: [],
        pic_str: '109951164627180052',
        pic: 109951164627180050
      },
      name: '拾梦纪',
      l: {
        br: 128000,
        fid: 0,
        size: 3760173,
        vd: -41672
      },
      rtype: 0,
      m: {
        br: 192000,
        fid: 0,
        size: 5640237,
        vd: -43277
      },
      cp: 1416668,
      mark: 0,
      rtUrl: null,
      mst: 9,
      dt: 234947,
      ar: [
        {
          id: 12084589,
          name: '妖扬',
          tns: [],
          alias: []
        },
        {
          id: 12578371,
          name: '金天',
          tns: [],
          alias: []
        }
      ],
      pop: 5,
      pst: 0,
      t: 0,
      v: 3,
      id: 1416767593,
      publishTime: 0,
      rurl: null
    }
];
useEffect(() => {
  if(!currentSong) return;
  changeCurrentIndexDispatch(0);//currentIndex默认为-1,临时改成0
  let current = playList[0];
  changeCurrentDispatch(current);//赋值currentSong
  audioRef.current.src = getSongUrl(current.id);
  setTimeout(() => {
    audioRef.current.play();
  });
  togglePlayingDispatch(true);//播放状态
  setCurrentTime(0);//从头开始播放
  setDuration((current.dt / 1000) | 0);//时长
}, []);

其中,getSongUrl为一个封装在api/utils.js中的方法:

//拼接出歌曲的url链接
export const getSongUrl = id => {
  return `https://music.163.com/song/media/outer/url?id=${id}.mp3`;
};

引入:

import { getSongUrl } from "../../api/utils";

但是你现在会看到这样的报错信息:

这是因为初始化store数据的时候,currentSong是一个空对象,song.al为undefined, 因此song.al.picUrl就会报错。

那怎么来规避这个问题呢?

很简单,我们在渲染播放器的时候判断一下currentSong是否对空对象就可以了。

import { isEmptyObject } from "../../api/utils";

//JSX
return (
  <div>
    { isEmptyObject(currentSong) ? null :
      <MiniPlayer
        song={currentSong}
        fullScreen={fullScreen}
        playing={playing}
        toggleFullScreen={toggleFullScreenDispatch}
        clickPlaying={clickPlaying}
      />
    }
    { isEmptyObject(currentSong) ? null :
      <NormalPlayer
        song={currentSong}
        fullScreen={fullScreen}
        playing={playing}
        toggleFullScreen={toggleFullScreenDispatch}
        clickPlaying={clickPlaying}
      />
    }
    <audio ref={audioRef}></audio>
  </div>
)

好,现在你打开项目应该可以听到背景音乐了,现在我们迈出了第一步。接下来就是一步步不断地完善播放的逻辑。

# 播放和暂停

首先是播放和暂停的逻辑。

其实之前已经完成,只不过没有和audio元素对接。现在通过监听playing变量来实现:

useEffect(() => {
  playing ? audioRef.current.play() : audioRef.current.pause();
}, [playing]);

现在在mini播放器可以看到效果,但是normalPlayer里面却没反应,现在补充上里面的逻辑。

//normalPlayer/index.js
const { song, fullScreen, playing } =  props;
const { toggleFullScreen, clickPlaying } = props;

//JSX中的修改
//CdWrapper下唱片图片
<div className="cd">
  <img
    className={`image play ${playing ? "" : "pause"}`}
    src={song.al.picUrl + "?param=400x400"}
    alt=""
  />
</div>
//中间暂停按钮
<div className="icon i-center">
  <i
    className="iconfont"
    onClick={e => clickPlaying(e, !playing)}
    dangerouslySetInnerHTML={{
      __html: playing ? "&#xe723;" : "&#xe731;"
    }}
  ></i>
</div>

# 进度控制

之前写的播放时间都是mock数据, 现在填充成动态数据。

//父组件传值
<NormalPlayer
  song={currentSong}
  fullScreen={fullScreen}
  playing={playing}
  duration={duration}//总时长
  currentTime={currentTime}//播放时间
  percent={percent}//进度
  toggleFullScreen={toggleFullScreenDispatch}
  clickPlaying={clickPlaying}
/>

同时有一点需要注意,就是audio标签在播放的过程中会不断地触发onTimeUpdate事件,在此需要更新currentTime变量。

const updateTime = e => {
  setCurrentTime(e.target.currentTime);
};
//JSX
<audio
  ref={audioRef}
  onTimeUpdate={updateTime}
></audio>

现在在normalPlayer中:

const { song, fullScreen, playing, percent, duration, currentTime } =  props;
const { toggleFullScreen, clickPlaying, onProgressChange } = props;

//相应属性传给进度条
<ProgressWrapper>
  <span className="time time-l">{formatPlayTime(currentTime)}</span>
  <div className="progress-bar-wrapper">
    <ProgressBar
      percent={percent}
      percentChange={onProgressChange}
    ></ProgressBar>
  </div>
  <div className="time time-r">{formatPlayTime(duration)}</div>
</ProgressWrapper>

ps: 其中,formatPlayTime为api/utils.js中的一个工具函数:

//转换歌曲播放时间
export const formatPlayTime = interval => {
  interval = interval | 0;// |0表示向下取整
  const minute = (interval / 60) | 0;
  const second = (interval % 60).toString().padStart(2, "0");
  return `${minute}:${second}`;
};

我要强调的重点是传给ProgressBar的两个参数,一个是percent,用来控制进度条的显示长度,另一个是onProgressChange,这个其实是一个进度条被滑动或点击时用来改变percent的回调函数。我们在父组件来定义它:

const onProgressChange = curPercent => {
  const newTime = curPercent * duration;
  setCurrentTime(newTime);
  audioRef.current.currentTime = newTime;
  if (!playing) {
    togglePlayingDispatch(true);
  }
};

//父组件传值
<NormalPlayer
  //...
  onProgressChange={onProgressChange}
/>

那么之前封装的进度条组件并没有处理percent相关的逻辑,现在在进度条组件中来增加。

const transform = prefixStyle('transform');

const { percent } = props;

const { percentChange } = props;

//监听percent
useEffect(() => {
  if(percent >= 0 && percent <= 1 && !touch.initiated) {
    const barWidth = progressBar.current.clientWidth - progressBtnWidth;
    const offsetWidth = percent * barWidth;
    progress.current.style.width = `${offsetWidth}px`;
    progressBtn.current.style[transform] = `translate3d(${offsetWidth}px, 0, 0)`;
  }
  // eslint-disable-next-line
}, [percent]);

const _changePercent = () => {
  const barWidth = progressBar.current.clientWidth - progressBtnWidth;
  const curPercent = progress.current.clientWidth / barWidth;
  percentChange(curPercent);
}

//点击和滑动结束事件改变percent
const progressClick = (e) => {
  //...
  _changePercent();
}

const progressTouchEnd = (e) => {
  //...
  _changePercent();
}

OK, 进度条被我们改了差不多了,现在就能够对接我们的播放器进度啦!

img

在最后,我们也把mini播放器的进度对接一下:

//父组件传值
<MiniPlayer
  //...
  percent={percent}
></MiniPlayer>
//miniPlayer/index.js
const { full, song, playing, percent } = props;

//JSX
<ProgressCircle radius={32} percent={percent}>
//...

做到这里大家可以完完整整地听一首歌了,实在不容易,接下来还有上一曲和下一曲的功能,我们慢慢来。

# 上下曲切换逻辑

//一首歌循环
const handleLoop = () => {
  audioRef.current.currentTime = 0;
  changePlayingState(true);
  audioRef.current.play();
};

const handlePrev = () => {
  //播放列表只有一首歌时单曲循环
  if (playList.length === 1) {
    handleLoop();
    return;
  }
  let index = currentIndex - 1;
  if (index < 0) index = playList.length - 1;
  if (!playing) togglePlayingDispatch(true);
  changeCurrentIndexDispatch(index);
};

const handleNext = () => {
  //播放列表只有一首歌时单曲循环
  if (playList.length === 1) {
    handleLoop();
    return;
  }
  let index = currentIndex + 1;
  if (index === playList.length) index = 0;
  if (!playing) togglePlayingDispatch(true);
  changeCurrentIndexDispatch(index);
};

这部分逻辑传给normalPlayer:

//传递给normalPlayer
handlePrev={handlePrev}
handleNext={handleNext}

在normalPlayer中绑定按钮点击事件:

const { toggleFullScreen, clickPlaying, onProgressChange, handlePrev, handleNext } = props;

//JSX
<div className="icon i-left" onClick={handlePrev}>
  <i className="iconfont">&#xe6e1;</i>
</div>
//...
<div className="icon i-right" onClick={handleNext}>
  <i className="iconfont">&#xe718;</i>
</div>

现在我们把父组件中控制歌曲播放的的逻辑完善一下:

//记录当前的歌曲,以便于下次重渲染时比对是否是一首歌
const [preSong, setPreSong] = useState({});

//先mock一份currentIndex
useEffect(() => {
  changeCurrentIndexDispatch(0);
}, [])

useEffect(() => {
  if (
    !playList.length ||
    currentIndex === -1 ||
    !playList[currentIndex] ||
    playList[currentIndex].id === preSong.id
  )
    return;
  let current = playList[currentIndex];
  changeCurrentDispatch(current);//赋值currentSong
  setPreSong(current);
  audioRef.current.src = getSongUrl(current.id);
  setTimeout(() => {
    audioRef.current.play();
  });
  togglePlayingDispatch(true);//播放状态
  setCurrentTime(0);//从头开始播放
  setDuration((current.dt / 1000) | 0);//时长
}, [playList, currentIndex]);

# 播放模式

分三种: 单曲循环、顺序循环和随机播放

我们先在Player/index.js,也就是父组件中写相应逻辑:

//从props中取redux数据和dispatch方法
const {
  playing,
  currentSong:immutableCurrentSong,
  currentIndex,
  playList:immutablePlayList,
  mode,//播放模式
  sequencePlayList:immutableSequencePlayList,//顺序列表
  fullScreen
} = props;

const {
  togglePlayingDispatch,
  changeCurrentIndexDispatch,
  changeCurrentDispatch,
  changePlayListDispatch,//改变playList
  changeModeDispatch,//改变mode
  toggleFullScreenDispatch
} = props;

const playList = immutablePlayList.toJS();
const sequencePlayList = immutableSequencePlayList.toJS();
const currentSong = immutableCurrentSong.toJS();

现在的需求是点击normalPlayer最左边的按钮,能够切换播放模式,我们现在在父组件写相应的逻辑。

顺便说一句。不知道你发现没有: 关于业务逻辑的部分都是在父组件完成然后直接传给子组件,而子组件虽然也有自己的状态,但大部分是控制UI层面的,逻辑都是从props中接受, 这也是在潜移默化中给大家展示了UI和逻辑分离的组件设计模式。通过分离关注点,解耦不同的模块,能够大量节省开发和维护成本。

//Player/index
const changeMode = () => {
  let newMode = (mode + 1) % 3;
  if (newMode === 0) {
    //顺序模式
    changePlayListDispatch(sequencePlayList);
    let index = findIndex(currentSong, sequencePlayList);
    changeCurrentIndexDispatch(index);
  } else if (newMode === 1) {
    //单曲循环
    changePlayListDispatch(sequencePlayList);
  } else if (newMode === 2) {
    //随机播放
    let newList = shuffle(sequencePlayList);
    let index = findIndex(currentSong, newList);
    changePlayListDispatch(newList);
    changeCurrentIndexDispatch(index);
  }
  changeModeDispatch(newMode);
};

目前的播放列表是在组件内mock的,现在已经不太合适,我们把mock列表移动到reducer中的defaultState中,这里就不展示了,要注意playList和sequenceList都要mock并且mock一样的数据。

接下来我们来解释一下changeMode中的内容,findIndex方法用来找出歌曲在对应列表中的索引,shuffle方法用来打乱一个列表,达成随机列表的效果,这两个函数都定义在api/utils.js中。

function getRandomInt(min, max) {
  return Math.floor(Math.random() * (max - min + 1) + min);
}
// 随机算法
export function shuffle(arr) {
  let new_arr = [];
  arr.forEach(item => {
    new_arr.push(item);
  });
  for (let i = 0; i < new_arr.length; i++) {
    let j = getRandomInt(0, i);
    let t = new_arr[i];
    new_arr[i] = new_arr[j];
    new_arr[j] = t;
  }
  return new_arr;
}

// 找到当前的歌曲索引
export const findIndex = (song, list) => {
  return list.findIndex(item => {
    return song.id === item.id;
  });
};

引入到父组件:

import { getSongUrl, isEmptyObject, shuffle, findIndex } from "../../api/utils";

接下来我们给normalPlayer传入:

<NormalPlayer
  //...
  mode={mode}
  changeMode={changeMode}
/>

现在就需要对normalPlayer做一些事情了:

//Operator标签下
<div className="icon i-left" onClick={changeMode}>
  <i
    className="iconfont"
    dangerouslySetInnerHTML={{ __html: getPlayMode() }}
  ></i>
</div>
//getPlayMode方法
const getPlayMode = () => {
  let content;
  if (mode === playMode.sequence) {
    content = "&#xe625;";
  } else if (mode === playMode.loop) {
    content = "&#xe653;";
  } else {
    content = "&#xe61b;";
  }
  return content;
};

其中playMode常量我们已经定义过,直接引入:

import { playMode } from '../../../api/config';

现在大家打开redux-devtools可以看到数据的变化,下面是随机模式

img

可以看到playList现在已经乱序了。

功能是实现了,但是只有一个图标放在这里,可能很多用户不知道是什么意思,如果能够文字提示一下,体验会更好一些。废话不多说,直接开始封装崭新的Toast组件,这里只是由于是侧重项目, 不可能将Toast的功能面面俱到,只是让大家体会一下封装的过程,以此来提升自己的内功,这也是我不用UI框架的原因。

在baseUI目录下新建Toast文件夹:

//Toast/index.js
import React, {useState, useImperativeHandle, forwardRef} from 'react';
import styled from 'styled-components';
import { CSSTransition } from 'react-transition-group';
import style from '../../assets/global-style';

const ToastWrapper = styled.div`
  position: fixed;
  bottom: 0;
  z-index: 1000;
  width: 100%;
  height: 50px;
  /* background: ${style["highlight-background-color"]}; */
  &.drop-enter{
    opacity: 0;
    transform: translate3d(0, 100%, 0);
  }
  &.drop-enter-active{
    opacity: 1;
    transition: all 0.3s;
    transform: translate3d(0, 0, 0);
  }
  &.drop-exit-active{
    opacity: 0;
    transition: all 0.3s;
    transform: translate3d(0, 100%, 0);
  }
  .text{
    line-height: 50px;
    text-align: center;
    color: #fff;
    font-size: ${style["font-size-l"]};
  }
`
//外面组件需要拿到这个函数组件的ref,因此用forwardRef
const Toast = forwardRef((props, ref) => {
  const [show, setShow] = useState(false);
  const [timer, setTimer] = useState('');
  const {text} = props;
  //外面组件拿函数组件ref的方法,用useImperativeHandle这个hook
  useImperativeHandle(ref, () => ({
    show() {
      // 做了防抖处理
      if(timer) clearTimeout(timer);
      setShow(true);
      setTimer(setTimeout(() => {
        setShow(false)
      }, 3000));
    }
  }))
  return (
    <CSSTransition in={show} timeout={300} classNames="drop" unmountOnExit>
      <ToastWrapper>
        <div className="text">{text}</div>
      </ToastWrapper>
    </CSSTransition>
  )
});

export default React.memo(Toast);

现在放到Player/index.js中使用:

import Toast from "./../../baseUI/toast/index";

//...
const [modeText, setModeText] = useState("");

const toastRef = useRef();

//...
const changeMode = () => {
  let newMode = (mode + 1) % 3;
  if (newMode === 0) {
    //...
    setModeText("顺序循环");
  } else if (newMode === 1) {
    //...
    setModeText("单曲循环");
  } else if (newMode === 2) {
    //...
    setModeText("随机播放");
  }
  changeModeDispatch(newMode);
  toastRef.current.show();
};

//JSX
return (
  <div>
    //...
    <Toast text={modeText} ref={toastRef}></Toast>
  </div>
)

效果:

img

那现在还有最后一个问题需要处理,就是歌曲播放完了之后,紧接着需要怎么处理。

我们回到父组件,把这个处理逻辑写在audio标签的onEnded事件回调中:

<audio
  ref={audioRef}
  onTimeUpdate={updateTime}
  onEnded={handleEnd}
></audio>

由于之前封装了下一曲和单曲循环的逻辑,这里就非常简单了。

import { playMode } from '../../api/config';
//...
const handleEnd = () => {
  if (mode === playMode.loop) {
    handleLoop();
  } else {
    handleNext();
  }
};

OK,到这里,mini/全屏播放器基本的功能都完成了!

阅读全文